PythonでのTDDを用いたOOP開発の簡単な例 - パート1
著者:Leonardo Giordani - 13/05/2015
はじめに
あなたがPythonを学びたいと思っていて、どうやって始めたらいいかわからない場合、この投稿がヒントになるかもしれません。この記事では、[* オブジェクト指向プログラミング(OOP: Object Oriented Programmiing)の技術と概念を例示し、テスト駆動開発(TDD: Test Driven Development)のアプローチを用いて、非常にシンプルなPythonパッケージをゼロから開発します。
このパッケージは、2進数を扱うクラスをいくつか提供しますが(「理由づけ」のセクションを参照)、これは単なるおもちゃのプロジェクトであることを忘れないでください。このパッケージは、パフォーマンスを考慮して設計されていません。
理由づけ
二進数は、慣れるまでに時間がかかったとしても、わりと簡単に理解できます。2進法の知識があることを前提としています。もし復習する必要があれば、ウィキペディアのBinary number (二進法) やインターネット上の無数のリソースの1つを見てください。 これから書くパッケージは、2進数を表すクラス(Binary)と、任意のビットサイズの2進数を表すクラス(SizeBinary)を提供する。これらのクラスは、論理演算(and, or, xor)、算術演算(加算、減算、乗算、除算)、シフト、インデックスなどの基本的なバイナリ操作を提供する。
このパッケージが行うべきことの簡単な例。
code: python
0123456789
>> b = Binary('0101110001')
>> hex(b)
'0x171'
>> int(b)
369
'1'
'0'
>> b.SHR()
'10111000'
Pythonと基数
二進法は、16進法(base16)や10進法(base10)のように、基数2の数字を表現するものです。Python は、内部的には常に 10 進数の整数として格納されているにもかかわらず、すでにネイティブに異なる基数を扱うことができます。確認してみましょう。
code: python
>> a = 5
>> a
5
>> a = 0x5
>> a
5
>> a = 0b101
>> a
5
>> hex(0b101)
'0x5'
>> bin(5)
'0b101'
ご覧のように、Pythonはいくつかの一般的な基数をすぐに理解し、16進数には0xという接頭辞を、2進数には0b(8進数には0o)という接頭辞を使用します。) しかし、数値は常に基数10の形式(この例では5)で表示されます。ただし、整数はこの操作をサポートしていないので、2進数にインデックスを付けることはできません。
code: python
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'int' object is not subscriptable
また、整数に変換する際に異なる基数を使用することができます。これにはbaseパラメータを使用します。
code: python
>> a = int('101', base=2)
>> a
5
>> a = int('10', base=5)
>> a
5
テスト駆動型の開発
新しい開発手法を試すには、簡単な作業が一番です。ですから、いわゆるテスト駆動型のアプローチを始めるには良い機会です。テスト駆動開発(TDD: Test Driven Develpment)とは、基本的に、開発の際に最初に行うことは、テストを書くことです。これらのプログラムの目的は、最終製品が所定の動作に準拠しているかどうかをテストすることです。テストは以下を提供します。
APIのドキュメント:あなたのパッケージの使用例を示します。
回帰性チェック:新機能を開発するためにコードを変更したとき、以前のバージョンのパッケージの動作を壊してはいけません。リグレッションテスト(Regression Test)とも言われます。
TODOリスト:すべてのテストが正常に実行されるまで、あなたはまだ何かを実装するのを待っているのです。
この記事に沿っていくつかのテストを書いた後(セクション「テストの作成」を含む)、自分のクラスを書き、すべてのテストにパスするようにすることをお勧めします。このようにして、実際に何かを開発することで、TDDとPythonの両方を本当に学ぶことができます。そして、自分のコードを私のコードと比較してチェックし、もしかしたら私が見つけたものよりもはるかに良い解決策を提供することができるかもしれません。
開発環境
これからの開発のために、簡単な環境を整えましょう。まず、Pythonの仮想環境を作成します。
code: bash
~$ python -m venv venv
訳注:原文では $ virtualenv -p python3 venv ですが、Python 標準ライブラリの venv に差し替えています。
また、以下 py.test とあるものは Pytest のバージョンが新しくなっていることから pytest に置き換えています。
そして、仮想環境を起動し、テストを書くために使用する pytest をインストールします。
code: bash
~$ source venv/bin/activate
(venv)~$ pip install pytest
次に、パッケージ、__init__.py ファイル、テスト用のディレクトリを作成します。
code: bash
(venv)~$ mkdir binary
(venv)~$ cd binary
(venv)~/binary$ touch __init__.py
(venv)~/binary$ mkdir tests
最後に、すべてが正しく動作していることを確認しましょう。Py.test はテストを見つけられなかったので、エラーなく終了するはずです。
code: bash
(venv)~/binary$ pytest
===================== test session starts =====================
collected 0 items
======================= in 0.00 seconds ======================
ここで [...] には、実行環境に関する情報が入ります。
Pytest
pytest のアプローチは非常に簡単です。ライブラリをテストするには、それを使用するいくつかの関数を書くだけです。これらの関数は例外を発生させずに実行されなければなりません。テスト(関数)が例外を発生させずに実行されれば合格、そうでなければ不合格です。まずは基本的な構文を学ぶために、とても簡単なテストを書いてみましょう。tests/test_binary.py ファイルを作成し、以下のコードを記述します。
code: python
def test_first():
pass
もう一度 pytest を実行すると、次のような結果が得られます。
code: bash
(venv)~/binary$ pytest
===================== test session starts =====================
collected 1 items
tests/test_binary.py .
=================== 1 passed in 0.01 seconds ==================
お好みで(私もそうですが)、冗長表示オプション-v を使って、どのテストが実行されたかの詳細情報を得ることができます。
code: python
(venv)~/binary$ pytest -v
===================== test session starts =====================
collected 1 items
tests/test_binary.py::test_first PASSED
=================== 1 passed in 0.01 seconds ==================
デフォルトの pytest は、名前が test_ で始まる Python ファイルを探します。各ファイルに対して、やはり名前が test_ で始まるすべての関数を実行し、test_first() が実行されたのはこのためです。
test_first() は何もしないので、例外を発生させることなく実行され、テストはパスします。では、例外を発生させてみましょう。
code: python
def test_first():
raise ValueError
これは、次のような pytest の結果が得られます。
code: bash
(venv)~/binary$ pytest -v
===================== test session starts =====================
collected 1 items
tests/test_binary.py::test_first FAILED
=========================== FAILURES ==========================
__________________________ test_first _________________________
def test_first():
raise ValueError
E ValueError
tests/test_binary.py:2: ValueError
=================== 1 failed in 0.01 seconds ==================
失敗したときに例外を発生させるテストを簡単に書くには、Pythonのassert文を使います。式が真の値を返した場合、assert は何もせず、そうでない場合は AssertionError 例外を発生させます。Python のREPLで簡単なチェックをしてみましょう。
code: python
>> assert True
>> assert False
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AssertionError
>>
>> assert 1 == 1
>> assert 1 == 2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AssertionError
そのため、通常、テストにはいくつかのコードと 1 つ以上のアサーションが含まれます。私は、同じ機能を複数回テストする場合(たとえば、リストからさまざまな要素を取得する場合など)を除いて、各テストにアサーションをひとつだけ入れることにしています。こうすることで、どのアサーションが例外を発生させたのかをすぐに知ることができ、 どの機能が期待通りに動作しないのかをすぐに知ることができるのです。
テストの作成
それでは、すでに Binary クラスを開発したと仮定して、 その動作をチェックするテストを書いてみましょう。ファイル全体は記事の最後にあるリソースセクションにありますが、ここではその一部を紹介します。
初期化:
code: python
from binary import Binary
def test_binary_init_int():
binary = Binary(6)
assert int(binary) == 6
これが私たちの最初の本格的なテストです。まず最初に、(まだ存在しない)binary.pyファイルからクラスをインポートします。test_binary_init_int() 関数は、その名のとおりバイナリを整数で初期化します。このアサーションでは、新しく作成されたバイナリ変数が、初期化に使用した数値である一貫した整数表現を持っているかどうかをチェックします。
ビット文字列('110')、バイナリ文字列('0b110')、16進文字列('0x6')、16進値(0x6)、整数のリスト([1,1,0])、文字列のリスト(['1','1','0'])など、さまざまな値でBinaryを初期化できるようにしたいと考えています。以下のテストでは、これらすべてのケースをチェックします。
code: python
def test_binary_init_bitstr():
binary = Binary('110')
assert int(binary) == 6
def test_binary_init_binstr():
binary = Binary('0b110')
assert int(binary) == 6
def test_binary_init_hexstr():
binary = Binary('0x6')
assert int(binary) == 6
def test_binary_init_hex():
binary = Binary(0x6)
assert int(binary) == 6
def test_binary_init_intseq():
assert int(binary) == 6
def test_binary_init_strseq():
assert int(binary) == 6
最後に、Binaryクラスが負の数で初期化できないことを確認しましょう。私は負の数を2の補数技法で表現することにしましたが、これにはあらかじめ定義されたビット長が必要です。そのため、単純なバイナリでは負の数を捨てています。
code: python
import pytest
def test_binary_init_negative():
with pytest.raises(ValueError):
binary = Binary(-4)
ご覧のように、今度はクラスが例外を発生させることをチェックしなければなりませんが、クラスに例外を発生させてしまうとテストは失敗してしまいます。テストを通過させるためには、例外が発生したことを確認し、それを抑制しなければなりません。これは、適切なコンテキストマネージャである pytest.raises で行うことができます。
変換の仕方
2 進数が整数 (int())、2 進文字列 (bin())、16 進数 (hex())、文字列 (str()) に正しく変換できるかどうかをチェックしたいと思います。文字列の表現は、0と1の単純なシーケンスであってほしいと思っています。
いくつかの例を紹介します(一連のテストについてはフルコードをご覧ください)。
code: python
def test_binary_int():
binary = Binary(6)
assert int(binary) == 6
def test_binary_str():
binary = Binary(6)
assert str(binary) == '110'
プロジェクトの構成
pytestを完璧に動作させるためのプロジェクトのレイアウトについては、このページを確認することをお勧めします。この記事に多くのことを書かないために、私はカスタマイズした環境変数 PYTHONPATH でpytest を実行して、コードを正しくインポートするつもりです。ただし、この設定は単純化のためのトリックに過ぎないことを覚えておいてください。Pythonのパッケージングやプロジェクトのレイアウトについては、次のJeff Knupp氏の詳細な記事をご覧ください。
クラスの作成
この時点でテストを実行しようとすると、binary.py モジュールがまだ存在していないため、インポートエラーによりテストスイートが失敗してしまいます。
code: bash
(venv)~/binary$ PYTHONPATH=. pytest -v
===================== test session starts =====================
collected 0 items / 1 errors
============================ ERRORS ===========================
____________ ERROR collecting tests/test_binary.py ____________
tests/test_binary.py:2: in <module>
from binary import Binary
E ImportError: No module named 'binary'
=================== 1 error in 0.01 seconds ===================
プロジェクトのルート(つまり、__init__.py や tests/ と一緒に作成した binary/ ディレクトリの中)に binary.pyというファイルを作成し、Binaryクラスの作成を始めましょう。
code: python
class Binary:
pass
pytestを実行すると、すべてのテストが実行可能であり、すべてのテストが失敗することがわかります(最初のテストだけを表示します)。
code: bash
(venv)~/binary$ PYTHONPATH=. py.test -v
===================== test session starts =====================
collected 13 items
tests/test_binary.py::test_binary_init_int FAILED
============================ FAILURES ==========================
_____________________ test_binary_init_int _____________________
def test_binary_init_int():
binary = Binary(6)
E TypeError: object() takes no parameters
tests/test_binary.py:5: TypeError
=================== 13 failed in 0.03 seconds ==================
これで、コードを書き始め、それが期待通りに動作するかどうかを テスト・バッテリー(Test Battery) (総合的な判定を行うために組み合わされた複数のテスト)を使ってチェックできるようになりました。もちろん、「期待通り」とは、書いたテストがすべて合格することを意味しますが、すべてのケースをカバーしているわけではありません。TDDは反復的な方法論です。バグや欠けている機能を見つけたら、まずその問題を解決するための良いテストや一連のテストを書き、次にそのテストが通るようなコードを作成します。
この時点で、あなたは自分でコードを書き、与えられたテスト群で自分の開発コードをチェックすることを温かく奨励されます。テストファイルの最初のバージョン (test_binary_ver1.py) をダウンロードして、tests/ ディレクトリに置いてください。テストファイルをよく読んで要件を理解してから、クラスを書き始めます。クラスの一部を書き終えたと思ったら、テストを実行して、すべてがうまくいくかどうかを確認し、次に進みます。 私のソリューション
このクラスの最も複雑な部分は初期化です。というのも、このクラスではさまざまなデータタイプを受け入れたいからです。基本的には、シーケンス(文字列やリスト)か、プレーンな値を扱わなければなりません。後者は整数に変換可能でなければなりません。そうでなければ、それらを使って2進数を初期化しようとしても意味がありません。2進数は整数の表現に過ぎないので、クラスの値を整数としてself._value属性に格納することにしました。つまり、Binary('000101') は Binary('101') と等しいということです。これは、インデックス作成やスライスの際に重要になります。
このコードは次のようになります。
code: python
import collections
class Binary:
def __init__(self, value=0):
if isinstance(value, collections.Sequence):
if len(value) > 2 and value0:2 == '0b': self._value = int(value, base=2)
elif len(value) > 2 and value0:2 == '0x': self._value = int(value, base=16)
else:
else:
try:
self._value = int(value)
if self._value < 0:
raise ValueError("Binary cannot accept negative numbers. Use SizedBinary instead")
except ValueError:
raise ValueError("Cannot convert value {} to Binary".format(value))
def __int__(self):
return self._value
テストを実行すると、0.02秒で4つの失敗、9つの合格が得られ、これは良いスコアです。まだ失敗しているテストは、 test_binary_eq、 test_binary_bin、 test_binary_str、 test_binary_hex です。変換のためのコードをまだ書いていないので、これらの失敗は予想されたことです。
私の書いたコードを見てみましょう。collectionsモジュールを使って、配列と普通の値をpythonic な方法で区別しています。もし抽象基底クラスが何であるかを知らなければ、この投稿と collections.abc モジュールのドキュメントを確認してください。
警告: Python 3.2 以下を使用している場合は、Python 3.3 で導入された collections.abc ではなく collections モジュールにこれらのクラスがあります。
基本的に isinstance(value, collections.abc.Sequence) を通じて、入力された値がシーケンスのように振る舞うかどうかをチェックしますが、これはリストや文字列、その他のシーケンスとは異なります。最初のケースでは、0bXXXXX という形式の文字列が入力され、int() 関数で整数に変換されます。2番目のケースは同じですが、0xXXXXX という形式の16進数の文字列を対象としています。
3つ目のケースは、個別に0または1に変換可能な汎用的な値の並びを対象としています。このコードでは、シーケンスの各要素を文字列に変換し、それらを1つの文字列に結合し、ベース2で変換します。これは、0と1の文字列の場合と、例えばリストのような整数の反復可能な文字列の場合をカバーしています。
入力された値が配列でない場合は、整数に変換しなければなりませんが、これはtryの部分で正確に行われます。ここでは、値が負の値であるかどうかもチェックし、適切な例外を発生させます。
最後に、多くのテストで行っているように、バイナリにint()を適用すると、__int__()メソッド(Pythonマジックメソッドの一つ)が自動的に呼び出されます。このメソッドは基本的に、与えられたクラスの整数への変換を提供する役割を担っています。この場合は、内部に保存した値を返すだけです。
もちろん、私はこのコードを一度に書いたわけではありません。自分のコードを調整するために、何度もテストを実行しなければなりませんでした。
整数への変換を行うメソッドはすでに書きました。しかし、一部のテスト(test_binary_bin と test_binary_hex)では、未だに TypeError: 'Binary' object cannot be interpreted as an integer というエラーメッセージが出て失敗します。
公式ドキュメントによると、「xがPythonのintオブジェクトでない場合は、整数を返す__index__()メソッドを定義しなければならない」とあるので、これが足りないのです。index__() については、ドキュメントによると、「一貫したint型クラスを持つためには、__index__() が定義されている場合、__int__() も定義されるべきであり、両方とも同じ値を返すべきである。」とあります。
とあるので、これをクラスに追加します。
code: python
def __index__(self):
return self.__int__()
すると、2つのテストが成功しました。
test_binary_str を通過させるには、 オブジェクトを文字列に変換するマジックメソッドを用意する必要があります。
code:python
def __str__(self):
bin() が提供する Python の内部アルゴリズムを利用して、0b のプレフィックスを除去します。
最後の失敗テストは test_binary_eq で、2 つの Binary オブジェクト間の等価性をテストします。
code: python
def test_binary_eq():
assert Binary(4) == Binary(4)
デフォルトでは、Pythonは非常に低いレベルでオブジェクトを比較し、2つの参照がメモリ内の同じオブジェクトを指しているかどうかをチェックするだけです。これをより賢くするために、__eq__() メソッドを提供しなければなりません。
code: python
def __eq__(self, other):
return int(self) == int(other)
そして、すべてのテストが正常に実行されるようになりました。
バイナリ演算
さて、いよいよBinaryクラスに新しい機能を追加していきましょう。すでに述べたように、TDDの方法論では、まずテストを書き、それからコードを書くことになっています。このクラスには、基本的な算術演算とバイナリ演算がありませんので、次のようなテストを行います(すべてのテストは、添付ファイルにあります)。
code: python
def test_binary_addition_int():
assert Binary(4) + 1 == Binary(5)
def test_binary_addition_binary():
assert Binary(4) + Binary(5) == Binary(9)
def test_binary_division_int():
assert Binary(20) / 4 == Binary(5)
def test_binary_division_rem_int():
assert Binary(21) / 4 == Binary(5)
def test_binary_get_bit():
binary = Binary('0101110001')
def test_binary_not():
assert ~Binary('1101') == Binary('10')
def test_binary_and():
assert Binary('1101') & Binary('1') == Binary('1')
def test_binary_shl_pos():
assert Binary('1101') << 5 == Binary('110100000')
最初の2つのテストでは、整数と2進数の両方を2進数に加えることが期待通りに動作することを確認しています。除算では、2 つの異なるケースをチェックします。整数の除算では余剰が生じるため、ここでは考慮しません。test_binary_get_bit 関数は、インデックス作成をテストするもので、複数のアサーションを含む数少ないテストの 1 つです。Python の標準的なシーケンスインデックスとは異なり、バイナリインデックスは右端の要素から始まることに注意してください。
ビット演算や算術演算は Python のマジックメソッドを使って実装されています。演算子と関連するメソッドの完全なリストは、公式ドキュメントを確認してください。
必要な動作を実装したコードは次のものになります。
code: python
def __and__(self, other):
return Binary(self._value & Binary(other)._value)
def __or__(self, other):
return Binary(self._value | Binary(other)._value)
def __xor__(self, other):
return Binary(self._value ^ Binary(other)._value)
def __lshift__(self, pos):
return Binary(self._value << pos)
def __rshift__(self, pos):
return Binary(self._value >> pos)
def __add__(self, other):
return Binary(self._value + Binary(other)._value)
def __sub__(self, other):
return Binary(self._value - Binary(other)._value)
def __mul__(self, other):
return Binary(self._value * Binary(other)._value)
def __truediv__(self, other):
return Binary(int(self._value / Binary(other)._value))
def __getitem__(self, item):
def __invert__(self):
すべてのメソッドは、おそらく__getitem__() と __invert__() を除いて単純明快です。__invert__() メソッドは、ビット単位のNOT演算(~)を行う際に呼び出され、負の数を避けるように実装されています。単純な解決策としては、Binaryを文字列に変換し、各桁を反転させることです(abs(int(i) - 1)は、'1'には0を、'0'には1を返す)。
また、__getitem__()メソッドにはインデックス機能が求められており、前述の通り、右端の要素から左に向かってインデックスを作成しなければなりません。これが -(item + 1) インデックスの理由です。なお、このメソッドは後にスライシングを完全にサポートするように変更される予定です。
スライシング
Binaryでもリストのようにスライスをサポートしたいと考えています。リストと私のBinary型の違いは、後者ではインデックスが右端の要素から始まることです。つまり、8ビットのBin`aryのビット3:7を取得すると、左端の4つのビットを取得することになります。望ましい動作は次のようになります。
code: python
>> b = Binary('01101010')
<binary.Binary object at 0x...> (110)
<binary.Binary object at 0x...> (101)
これは、テストに即座に変換することができます。
code: python
def test_binary_slice():
assert Binary('01101010')0:3 == Binary('10') assert Binary('01101010')1:4 == Binary('101') assert Binary('01101010')4: == Binary('110') テストを実行すると、__getitem__() 関数がスライスオブジェクトをサポートしていないことがわかり、
例外 TypeError: unsupported operand type(s) for +: 'slice' and 'int' が発生しました。__getitem__()のドキュメントを確認すると、整数とスライスオブジェクトの両方を管理すること(これが欠落している部分です)と、forループを機能させるために不正なインデックスに対してIndexErrorを発生させることが書かれています。そこで、すぐにこのルールのテストを追加しました。
code: python
def test_binary_illegal_index():
with pytest.raises(IndexError):
などのテストを行い、正しい動作になるようにしています(これらのテストは完全なコードに記載されています)。先頭のゼロが取り除かれることを覚えておいてください。したがって、インデックス番号7を取得することは、たとえ入力された文字列が8文字であっても、バイナリ番号が7桁しかないために失敗します。
インデックスを逆にしてリストスライスの動作全体を再実装するのではなく、それを利用する方がずっと簡単です。文字列バージョンのバイナリを受け取り、その逆バージョンをスライスして、その結果(再び逆バージョン)を返せばいいのです。ただし、スライスの結果は単一の要素である可能性があるため、Sequenceクラスと照合する必要があります。
code: python
def __getitem__(self, key):
sliced = reversed_list.__getitem__(key)
if isinstance(sliced, collections.abc.Sequence):
if len(sliced) > 0:
else:
return Binary(0)
else:
return Binary(sliced)
最初のリスト内包では、2進数を反転させた整数のリストを返します(つまり、Binary('01101')から[1, 0, 1, 1]まで、先頭のゼロは取り除かれることを覚えておいてください)。そして、スライスをリスト型に委ね、__getitem__()を呼び出します。この呼び出しの結果は、シーケンスまたは単一の要素(整数)である可能性があるので、2つのケースを区別します。最初のケースでは、結果を再び反転させ、2番目のケースでは、単に結果を返します。どちらの場合も、Binaryオブジェクトを作成します。リストの長さをチェックする必要があるのは、スライスが要素を返さない可能性があるからですが、Binaryクラスは空のリストを受け入れません。
バイナリの分割
最後に追加したい機能は、2進数を2つのバイナリに分割するsplit()関数です。右端のバイナリは指定されたサイズのビットを持ち、左端のバイナリは残りのビットを含むだけです。以下のテストでは、split()の動作を例示しています。
code: python
def test_binary_split_no_remainder():
assert Binary('110').split(4) == (0, Binary('110'))
def test_binary_split_remainder():
assert Binary('110').split(2) == (1, Binary('10'))
def test_binary_split_exact():
assert Binary('100010110').split(9) == (0, Binary('100010110'))
def test_binary_split_leading_zeros():
assert Binary('100010110').split(8) == (1, Binary('10110'))
これらの新しいテストは test_binary_ver4.py というファイルにあります。
それを実装したコードは次のものです。
code: python
def split(self, bits):
参考資料
Python 公式ドキュメント
最後に]
もしあなたが私のソリューションをチェックする前に自分のクラスを書いてみたなら、テストが失敗したときのフラストレーションと、最終的にパスしたときの大きな喜びの両方を経験したことでしょう。また、TDDのシンプルさを理解し、多くのプログラムがTDDを採用している理由を理解できたのではないかと思います。
次回の記事では、TDDの手法を用いてSizeBinaryクラスの追加を行います。